Skip to content

Add Echo-TTS (community model) - #180

Open
5uck1ess wants to merge 18 commits into
0xShug0:mainfrom
5uck1ess:echo-tts-port
Open

Add Echo-TTS (community model)#180
5uck1ess wants to merge 18 commits into
0xShug0:mainfrom
5uck1ess:echo-tts-port

Conversation

@5uck1ess

@5uck1ess 5uck1ess commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Ready for review. The DiT denoiser passes its parity gate against PyTorch; the remaining gaps are listed honestly under What's still missing, and the two open questions are structural ones only you can answer — see Scope.

Note on ENGINE_BUILD_TESTS: a full build currently fails on three tests/moss_tts_local/*_parity.cpp files. That is pre-existing on main, not from this branch — those files and every moss source here are byte-identical to upstream, and I reproduced the identical error in a clean upstream/main worktree configured from scratch. Flagging it so it isn't attributed to this PR.

Adds Echo-TTS as a community model: an English zero-shot voice-cloning TTS model. A 2.8B diffusion transformer generates 80-D latents in PCA space, decoded to 44.1 kHz by the Fish S1-DAC autoencoder. Cloning needs a reference wav, no transcript. Text is byte-level UTF-8 — no phonemiser, no G2P, no pronunciation dependency.

Echo-TTS is on the candidate list in #34 (struck through, "contributions are welcome").

Credit

The implementation is @dignome's. They built it from this PR's design notes, published it at dignome/audio.cpp-echo-tts, and offered it here rather than opening a competing PR. I've integrated it, reviewed it, cut the parts that don't belong upstream, and fixed what the reviews found. Their commits are preserved as Co-authored-by.

Evidence

Numerical parity against PyTorch

tests/echo_tts/echo_tts_dit_parity.cpp against a dump from tools/community_models/echo_tts_reference.py. Gates are cosine over the flattened tensors and max-absolute-error — cosine alone cannot see a uniform scale error, and the host Philox stream matches CUDA to ~2 ULP rather than bit-exactly.

The reference defaults to bfloat16; this GGUF is F16. Both dumps are shown because the difference between them turns out to be the single largest term:

Check vs bf16 ref vs f16 ref Gate Verdict
Denoiser, one conditional forward at t = 0.7 0.999977 0.999999188, max-abs 0.010 ≥ 0.999 PASS
40-step sampler, reference initial noise injected 0.913082 0.988459, max-abs 1.07 ≥ 0.999 below gate
40-step sampler, our own seeded draw 0.905481 0.976972, max-abs 1.87 ≥ 0.999 below gate

The denoiser probe is the number that carries this port. It settles the four details that fail silently — half-head RoPE, the interleaved (not NEOX) rotary pairing, the speaker patchify reshape, and the adaLN shift/scale/gate order.

It does not isolate the DiT blocks alone: the reference text ids and speaker latents are injected, but prepare_conditioning() then runs this port's own text encoder, speaker encoder and KV projections. Enough to catch a wrong block, not enough to localise one. Per-block dumps exist in the packer (--blocks) and have not been run.

The 40-step trajectory is below the gate and is reported as a FAIL of the check as written. What is established:

  • Not the RNG — injecting the reference's own initial noise scores no better than our seeded draw (0.988 vs 0.977).
  • Dominated by dtype — re-dumping the reference at float16 moves the trajectory 0.905 → 0.977 and the denoiser 0.999977 → 0.999999.
  • Compounds with step count — 0.9965 at 4 steps, 0.9055 at 40 (bf16 ref). Monotonic degradation is the signature of accumulating per-step rounding amplified by dual CFG at 3.0/8.0, not a structural defect.
  • A line-by-line review of the sampler against inference.py found no defect — schedule, inclusive CFG bounds, single application of truncation_factor, three-lane CFG combination, the Euler update, and the speaker-KV boundary all agree.

That is an explanation, not a proof. Treat the trajectory as unverified.

End to end

Executed on an RTX 3090 (sm_86), CUDA, F16 GGUF converted locally from jordand/echo-tts-base + jordand/fish-s1-dac-min:

Check Result
Conversion manifest OK — 1117 DiT tensors, 219 blockwise tensors dropped, 495 codec tensors
GGUF verifier pass — 1614 tensors, F16 1043 / F32 571 (norm weights held at F32)
latent_scale 0.0555555559694767 (= 1/18), matching the reference
Generation exit 0, 44 100 Hz mono, no NaNs, peak 0.80
ASR round-trip, 32 words WER 0.0 %, 0 edits (faster-whisper-large-v3-turbo)
Throughput 9.195 s audio in 7.89 s wall — RTF 0.86 cold, including the 5.5 GB load

WER on 32 words is a small sample and scores intelligibility only. It shows the pipeline runs end to end and produces the right words; it does not score speaker identity, so a wrong patchify reshape could yield fluent, correctly-worded speech in the wrong voice and still read 0 %. The denoiser cosine above is what covers that.

Generation cost is essentially constant between a 15-word and a 32-word run (7.75 s vs 7.89 s) because the window is fixed at 640 frames, so longer text inside a chunk is close to free.

Independent reviews

Two rounds, Codex (GPT-5.6) and Grok 4.6 each time — first on the contributed tree before adopting it, then on my own verification commits. Codex checked the ggml port line-by-line against the reference PyTorch; Grok judged adoption and upstream fitness.

Codex cleared the four items the original author flagged as possibly-silently-wrong: half-head RoPE rotates the first heads/2 on the head axis (model.py:199,217), GGML_ROPE_TYPE_NORMAL is the correct interleaved convention against model.py:21's reshape(...,-1,2) pairing, the patchify reshape gives frame-then-channel ordering per model.py:458, and adaLN order is shift/scale/gate per model.py:64. Its one CANNOT-TELL — tensor names — was settled by running the converter against the real checkpoint.

Both then independently flagged the same feature, and Codex supplied the mechanism:

The adaptive generation window is now off by default. It shrank the 640-frame window to a text-length estimate, justified in-comment as costing "time, never fidelity" because a missing flattening point retries at full length on the same Philox seed. The noise claim is true; the fidelity conclusion doesn't follow, because the seed isn't what changes. Echo's generated self-attention is fully non-causal — self_mask = torch.ones((batch_size, seq_len)) at model.py:249 — so every latent position attends across the whole window. Shrinking 640 to 128 changes the computation at every retained position, not just how many survive. And the retry only fires when no flattening point is found, so a short window that yields a plausible flat tail is never corrected. The optimisation stays, behind AUDIOCPP_ECHO_TTS_ADAPTIVE_WINDOW=1.

Scope

Cut from the contributed branch before landing: the root README.md dump, echotts-server.json (hardcoded machine paths), webui dist/ and package-lock.json build artifacts, and validate_model_spec.py (a generic schema checker that isn't Echo-specific and deserves its own PR).

Two things here are not additive and I'd rather flag them than have you find them:

  • src/models/fish_audio/codec.cpp. Echo reuses the in-tree Fish codec, which needs continuous z_q access. Two separate things, and I had originally described them as one:

    The decode side is a pure refactor. build_decode_quantizer was split into build_zq_from_codes + build_decode_from_zq and reassembled as a composition of the two. Same ops, same order, same graph. I earlier called this "restructured"; that overstated it.

    The encode side did change behaviour, and I've since fixed it. &z_q was passed unconditionally, so every fish_audio request built an extra ggml_sub node and marked it ggml_set_output even though fish_audio never reads it. The arithmetic was unaffected, but ggml_set_output pins that buffer and keeps both x and the quantiser residual live to the end of the graph, where otherwise the residual is free to be reused — a real allocation change for a family that gains nothing from it. EncodeGraph now takes want_z_q; with it false no node is built, no output is set, nothing is expanded, so the construction sequence is identical to upstream's. encode_reference passes false, only encode_zq passes true, and matches() carries the flag so the two graphs can't be confused.

    That shrinks the blast radius to zero for existing fish_audio users, which I think is a better answer than a test proving I didn't break it. Still happy to split this into a preceding PR if you'd rather review it on its own.

  • src/framework/audio/wav_reader.cpp (+176). PCM8/PCM32/float64/A-law/mu-law/WAVEFORMATEXTENSIBLE support. This is a general WAV improvement that Echo doesn't strictly need. Say the word and I'll pull it into a separate PR.

Long-form and the fixed window

Echo generates at most 640 latents (640 × 2048 ÷ 44100 = 29.7215 s). Upstream's blockwise sampler subdivides that window rather than extending it (sum(block_sizes) + continuation_length < 640), and upstream notes it "hasn't been thoroughly tested."

So long text goes through the framework chunker, as the rest of the repo does — runtime::chunk_text_request at 300 codepoints, speaker conditioning cached once per session, append_audio_buffer to concatenate. chatterbox is the closest analogue. Dropping the blockwise path also drops latent_encoder, wk_latent and wv_latent: the 219 tensors the converter reports discarding, ~294 M parameters.

long_form is deliberately not claimed in capabilities. It appears nowhere in the C++, and only 5 of 22 TTS/clone families declare it — the 17 that don't include chatterbox, fish_audio, higgs_audio_tts, index_tts2, qwen3_tts, voxcpm2 and pocket_tts. Happy to add it if you'd rather it be declared.

What's still missing

  • No fish_audio regression test, and no fish_audio checkpoint on my machine to build one against. The z_q output is now opt-in (see Scope), so fish_audio's own graph is identical to upstream's and there is nothing left for such a test to catch — but that is an argument from construction, not from execution, and I'd rather say so than imply I ran it.
  • No per-block DiT activation dump, so the passing denoiser cosine proves correctness without localising where a future regression would live.
  • The 40-step trajectory residual is explained but not closed.
  • No A/B of the flash-attention path against AUDIOCPP_ECHO_TTS_NO_FLASH=1 on a fixed seed.
  • No listening comparison of F16 against Q8_0. Q8 works but hasn't been auditioned, so the docs don't recommend it.
  • Warm RTF and VRAM-stability-across-requests numbers.
  • combine_cfg_lanes and euler_timestep_schedule have no registered coverage. They were checked by hand (6.6e-07 and 0.0 diff against a numpy transcription of inference.py); that hand run is not what CI holds, and the docs now say so.

Registered tests: tests/echo_tts/echo_tts_host_units.cpp via add_engine_unittest/add_test, CPU-only, no checkpoint needed — WhisperD normalisation, full byte-token id vectors, truncation and padding, PCA projection and inversion pinned independently against hand-computed values on a rectangular non-symmetric basis, and the flattening crop including both its thresholds. Mutation-checked against the four defects the second review said the earlier fixtures couldn't catch (transposed basis, mean dropped on both legs, scale dropped on both legs, zero-window search instead of thresholds); all four now fail the suite.

Licence

Echo-TTS is CC-BY-NC-SA-4.0, and the restriction covers generated audio, not just the weights (inherited from the Fish S1-DAC dependency). Flagging it rather than leaving it to be inferred. There's in-tree precedent — fish_audio carries the identical restriction from the identical dependency — and audio.cpp's Apache 2.0 licence is unaffected, since weights are a separate download. Documented in the model doc so nobody ships product on non-commercial output.

One question

Anything you'd want structured differently — file layout, option naming, whether the fish_audio and wav_reader changes should land as their own PRs first, or whether this belongs in community_models at all.

Why this model

Picked by comparing every model tracked in tts-bench — 62 local TTS models benchmarked across speed, objective scores, and blind human preference — against the existing support table.

Measure Echo-TTS Field
Blind cloning Elo 1162 #3 of 40 (35 games, 738 cloning votes)
Speaker similarity 0.836 2nd of 41
UTMOS / WER 4.21 / 7.45 %
Frozen pairwise study 21-1-6 near-tied 1st of 28

Caveats worth stating: the cloning arena averages ~30 games per model, so gaps under ~100 Elo are noise, and the ranking uses a single reference clip. Echo is top-3 on votes and 2nd on objective SIM, which are independent measurements.

@0xShug0 0xShug0 added the new model Request for new model support label Aug 14, 2026
@dignome

dignome commented Aug 19, 2026

Copy link
Copy Markdown

I had a go at it. Can use anything you want from here if it helps.

https://github.com/dignome/audio.cpp-echo-tts

@5uck1ess

Copy link
Copy Markdown
Contributor Author

@dignome appreciate it. i was all out of tokens building a SaaS. will take a look and implement.

5uck1ess and others added 12 commits August 20, 2026 04:07
Design for porting Echo-TTS (jordand/echo-tts-base, 2.8B DiT + Fish
S1-DAC) into audio.cpp as a community model.

Architecture verified against upstream source and safetensors headers,
not inferred. Key findings:
- EchoDiT: 24 blocks, d=2048, joint attention, adaLN, byte-level text
- Fixed 640-latent / 29.72s generation window
- Blockwise path subdivides that window, does not extend it
- Decode and encode need near-disjoint Fish submodules
- 303.6M of the Fish checkpoint is regenerable buffers, not weights

Staged M0-M4 with per-milestone gates and a hard Definition of Ready
before the PR leaves draft.
- Resolve RoPE theta open question (10000.0, complex-valued, model.py:9)
- Add timestep embedding formula
- Warn RTF vs RTFx are inverses (tts-bench vs audio.cpp conventions)
- Cite the actual schema validator for the M0 gate
- Define 'cosine' precisely (flattened 1-D, with max-abs-error)
- Add decomposition note: M0+M1 in one plan, M2/M3/M4 separate
13 tasks, each gated on executed evidence:
- M0 (T1-2): spec v1 registration + draft PR
- M1 (T3-13): converter, parity dumps, GGUF, assets, tokenizer,
  text/speaker encoders, 24-block DiT, dual-CFG Euler sampler,
  PCA inverse + Fish decode, crop, warm bench

Every stage gates on cosine >= 0.999 vs PyTorch before the next
begins. Task 12 requires a human ear check - tensor parity cannot
catch a wrong flattening-point crop.

PR stays draft through M1; cloning still needs an injected .npy
until M2 lands native speaker encoding.
- Repo has no CMakePresets.json; --preset would fail. Use
  scripts/build_linux.sh or cmake --build build/linux-cuda-release.
- Existing build tree pins CMAKE_CUDA_ARCHITECTURES=75 (Turing) on an
  sm_86 card. Task 13 now reconfigures to 86 before measuring RTF,
  otherwise the number is invalid.
- Note AUDIOCPP_MODEL_SET=full so the family compiles in automatically.
Baseline on this branch is registered_loaders=42, verified. Also note
that a 'requires a schema v1 model contract' failure means a stale
binary, not a broken tree.
Plan Task 1. Spec-backed loader (no loader.cpp), schema_version 1,
capabilities.clone deliberately omits long_form until M3 earns it.

Verified by execution: registered_loaders 42 -> 43, echo_tts appears
as 'clon (offline)', spec parses.

Fix over Codex's draft: guard used VoiceTaskKind::Tts, but the family
registers as a clone task, so every real invocation would have thrown.
Corrected to VoiceCloning, matching confucius4_tts:185. The
registration gate could not catch this - --list-loaders enumerates
loaders without constructing a session.
Documents the fixed 29.72s window, why blockwise does not extend it,
the CC-BY-NC-SA output restriction (with the fish_audio precedent),
benchmark provenance, options, and the WhisperD text format.

Also drops an unrelated .gitignore change that was accidentally
swept into an earlier docs commit, so the PR diff stays scoped.
The design spec and implementation plan are our working process, not
content for audio.cpp. Preserved on the local echo-tts-planning branch
and still on disk; just untracked here so the PR diff stays scoped to
the actual contribution.
…rter

Replaces the M0 silence stub with a complete port, contributed by
@dignome and offered for use in this PR (see PR 0xShug0#180 discussion).

  DiT trunk, 24 blocks, joint attention with flash-attn path
  Byte tokenizer + WhisperD normalisation
  Euler dual-CFG sampler with independent text/speaker guidance
  PCA inverse + flattening-point crop
  Fish S1-DAC z_q seam, reusing the in-tree fish_audio codec
  GGUF converter (F16 and Q8_0) plus a manifest and verifier
  Long-form via the framework text chunker at 300 codepoints

Scope trimmed from the source branch before landing: the root README
dump, echotts-server.json (hardcoded machine paths), webui build
artifacts, and a generic validate_model_spec.py that is not
Echo-specific and belongs in its own PR.

Two corrections on top of the contributed tree:

  resolve_reference_max_samples' comment claimed it falls back to the
  trained maximum; it returns kDefaultReferenceMaxSamples (15 s). The
  code is intentional and the spec publishes 15.0 in both scopes, so
  the comment was the error, not the behaviour.

  The status table still carried this PR's original milestone list,
  which said M2 was not started and that cloning needed a
  pre-computed speaker latent. session.cpp calls codec_->encode_zq
  directly, so both claims were false. Rewritten to separate what is
  implemented from what is numerically verified, because nothing in
  the ggml graph has been checked against PyTorch yet.

Co-authored-by: dignome <dignome@gmail.com>
Independent reviews by Codex and Grok both landed on this feature, from
different angles. Codex supplied the mechanism.

The code defaulted the generation window to a text-length estimate,
justified in-comment as "an under-estimate costs time, never fidelity",
because a missing flattening point retries at full length and
generate_torch_cuda_randn is a sequential Philox stream, so the retry
draws bit-identical noise.

The noise claim is true. The fidelity conclusion does not follow, because
the seed is not what changes. Echo's generated self-attention is fully
non-causal -- model.py:249 is

    self_mask = torch.ones((batch_size, seq_len), dtype=torch.bool, ...)

so every latent position attends across the whole window. Shrinking 640
to 128 changes the computation at every retained position, not merely how
many positions survive. The reference defaults to 640 (inference.py:353).

The retry also only fires when no flattening point is found, so a short
window that happens to produce a plausible flat tail is never corrected
and silently ships different audio.

Inverted to AUDIOCPP_ECHO_TTS_ADAPTIVE_WINDOW=1. The cost saving is real
and the implementation stays; only the default changes, until it has been
A/B'd against the full window on a fixed seed.

Codex also cleared the four highest-risk items the original author flagged
as possibly-silently-wrong, against the reference: half-head RoPE rotates
the first heads/2 on the head axis (model.py:199,217), GGML_ROPE_TYPE_NORMAL
is the correct interleaved convention against model.py:21's reshape(...,-1,2)
pairing, the speaker patchify reshape produces frame-then-channel ordering
matching model.py:458, and the adaLN chunk order is shift/scale/gate per
model.py:64. Tensor names remain unproven pending a real checkpoint.
Adds the models.md row, with attribution to @dignome for the
implementation. Flagged by review as the one doc that was never
updated.
Executed on an RTX 3090 (sm_86) against a locally converted F16 GGUF.
Conversion reports manifest OK, the verifier passes, and generation
round-trips through faster-whisper at 0.0% WER on 32 words. Throughput
is RTF 0.86 cold, including the 5.5 GB load.

A 0% WER rules out the silent-wrong failure modes -- half-head RoPE,
rotary pairing, patchify layout and adaLN chunk order would each produce
fluent but incorrect speech. It is still not per-tensor parity, so the
missing evidence is listed explicitly rather than implied: no cosine
gate against PyTorch, no flash-attn A/B, no fish_audio regression test
for the restructured build_decode_quantizer, no F16-vs-Q8 listen, and
no registered C++ tests.
@5uck1ess 5uck1ess changed the title Add Echo-TTS (community model) — draft, opening early per #54 Add Echo-TTS (community model) Aug 20, 2026
@5uck1ess

5uck1ess commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

@dignome this was extremely useful — thank you. I've integrated it and it's now the body of this PR, with your commits preserved as Co-authored-by and credit in the model doc and models.md.

It works. Converted an F16 GGUF locally from jordand/echo-tts-base + jordand/fish-s1-dac-min and ran it on a 3090:

  • manifest OK — 1117 DiT tensors, 219 blockwise dropped, 495 codec
  • GGUF verifier passes, 1614 tensors, norm weights held at F32
  • ASR round-trip through faster-whisper-large-v3-turbo: 0.0% WER, 0 edits on 32 words
  • 9.195 s of audio in 7.89 s wall — RTF 0.86 cold, including the 5.5 GB load

Sounds good too, not just measurable-good.

I ran it past Codex and Grok before adopting. Codex checked your five "most likely to be wrong" items against the reference PyTorch line by line and cleared four of them — half-head RoPE on the head axis (model.py:199,217), GGML_ROPE_TYPE_NORMAL as the correct interleaved pairing against model.py:21, the patchify reshape ordering (model.py:458), and adaLN shift/scale/gate (model.py:64). Your fifth, the tensor names, got settled by the converter itself printing manifest OK against the real checkpoint. Your instinct about which parts were dangerous was right, and the parts you were worried about turned out fine.

One real find, and both reviewers hit it independently. The adaptive generation window defaulted on, with the reasoning that an under-estimate "costs time, never fidelity" because the retry reuses the Philox stream. The noise argument is correct but the conclusion doesn't follow, because the seed isn't what changes — model.py:249 is self_mask = torch.ones((batch_size, seq_len)), so generated self-attention is fully non-causal and every latent position attends across the whole window. Going 640 → 128 changes the computation at every retained position, not just how many survive. And the retry only fires when no flattening point is found, so a short window that yields a plausible flat tail never gets corrected. I've kept the implementation and moved it behind AUDIOCPP_ECHO_TTS_ADAPTIVE_WINDOW=1 — the cost saving is real, it just can't be the default until it's been A/B'd on a fixed seed.

Trimmed for upstream scope: the root README section, echotts-server.json, the webui dist/ and lockfile artifacts, and validate_model_spec.py (genuinely useful, but generic — worth its own PR). Folded the perf writeup into docs/community_models/echo_tts_performance.md.

Still draft. Missing a real parity pass against PyTorch, and a fish_audio regression test — your z_q seam restructured build_decode_quantizer rather than just extending it, so that core family's decode path changed and needs its own coverage before this can merge.

Genuinely appreciate you publishing it and offering it up instead of racing a competing PR.

hats off to you sir @dignome

The contributed port's host-side units were verified by hand and never
committed as tests. This registers them as a CPU test target that needs
neither a GPU nor the 5.5 GB checkpoint, so CI can hold them.

Coverage:
  WhisperD normalisation, including the asymmetric double-quote rewrite
    upstream applies to U+201D but not U+201C. That asymmetry looks like
    a bug and is load-bearing -- "fixing" it silently desyncs the token
    stream from the reference, so the test pins it.
  Byte tokenisation: exact token counts and prefixes, [S1] tagging,
    bracket/paren suppression, truncation at 768 including the BOS.
  PCA forward/inverse round trip on an orthonormal basis, plus a
    separate assertion that latent_scale is applied, so dropping it on
    either leg fails rather than cancelling out.
  find_flattening_point on three cases: a mid-sequence flattening, a
    latent that never flattens, and one flat from frame zero.

Every expected value was produced by executing the reference
implementation (inference.py tokenizer_encode / find_flattening_point),
not by reasoning about what it should return.

Verified by mutation rather than by the tests merely passing: removing
the colon rewrite fails the normalisation case, and dropping the inverse
PCA scale fails the round trip at element 0. Both reverted.
The design spec and implementation plan are working process, not content
for audio.cpp; a git add -A swept them in along with a local .gitignore
edit. Files stay on disk and untracked.
Adds the numerical parity evidence the PR was missing. A 0% WER shows the
pipeline is right end to end; it does not show the DiT graph matches
PyTorch, which is what the silent-wrong failure modes would break.

  tests/echo_tts/echo_tts_dit_parity.cpp
      Loads the GGUF, injects the reference's own text ids, mask and
      speaker latent, and scores by cosine plus max-absolute-error.
      Cosine alone hides a uniform scale error and max-abs alone is
      dominated by one outlier, so both are reported.
      Not registered with add_test -- it needs a 5.5 GB GGUF and a
      PyTorch dump, so it is hand-driven like dots_tts_vocoder_parity.

  tools/community_models/echo_tts_pack_reference.py
      Packs echo_ref.npz into a flat binary so the harness needs no npz
      parser in C++.

  EchoDitRuntime::denoise_once(x, t, lanes)
      A testing seam. sample() alone cannot isolate a wrong block from a
      wrong integration step; feeding the reference's own x and t makes
      any difference attributable to the graph.

Result on an RTX 3090 against the F16 GGUF, reference dumped from
upstream at a fixed seed and a fixed timestep t=0.7:

    denoiser   cosine=0.999976711  max_abs=0.086061  rms=0.008939  PASS

That clears the 0.999 gate and settles the four items flagged as
possibly-silently-wrong: half-head RoPE, the rotary pairing convention,
the speaker patchify reshape and the adaLN chunk order all now have a
number behind them rather than a code reading.

The full 40-step trajectory from our own seeded noise scores cosine
0.905, below the gate. Not yet reported as pass or fail: the harness now
also runs the sampler from the reference's OWN initial noise, which
discriminates between RNG divergence and a real integration defect. That
run is queued behind an unrelated tts-bench job holding the GPU.
The seam recomputed sequence_length as x.size() / (latent_size * lanes),
but x is always a SINGLE lane: sampler.cpp calls denoise(x_t, t, 3) with
an x_t of exactly `elements` and expects elements * 3 back. `lanes`
selects the width of the OUTPUT, not the input. The three-lane path
therefore threw on a non-divisible size instead of running.

Caught by executing the parity harness, not by reading it.
… cosines

Codex and Grok reviewed the verification commits (not dignome's port) and
converged on the same four things. All are addressed here.

Parity harness
--------------
max-absolute error was computed, printed, and never gated -- `actual =
1000 * expected` scored cosine 1.0 and PASSed. Both checks now require
cosine AND max-abs, with `--denoiser-max-abs` / `--sampler-max-abs`.
Verified by running the denoiser probe at `--denoiser-max-abs 0.001`:
cosine still 0.999999, verdict FAIL.

The header claimed the probe attributes any difference "to the graph
alone". It does not: `prepare_conditioning()` runs this port's own text
encoder, speaker encoder and KV projections, so the number covers the
combined conditioning-plus-denoiser path. Corrected in the header and the
model doc.

The bundle reader took signed name/element lengths straight from the file
into `resize()` and treated every dtype tag other than 1 as float. Bounded
and validated; the little-endian assumption is now stated rather than
implied.

Host unit tests
---------------
The PCA fixture was a square identity, which is its own transpose, so a
transposed basis read passed, and a mean or scale dropped on *both* legs
cancelled in the round trip. Replaced with a rectangular, non-symmetric
orthonormal basis (2 components over 4 features), with projection and
inversion each pinned against independently computed values -- confirmed
against numpy. The round trip is now exact to 1e-6 on an in-subspace
vector rather than 1e-3 on a bijection.

Token tests checked a length and a 12-id prefix; a length-preserving
rewrite (signed-char sign extension on multibyte UTF-8) passed. Full id
vectors are now pinned for all five cases, generated by executing
`tokenizer_encode`.

The flattening fixtures were all extreme active-or-zero, so an
implementation that merely searched for an all-zero window passed without
evaluating either threshold. Added a quiet-but-non-zero tail (0.02 -> 30)
and a flat-but-loud tail (0.5 -> 60), both confirmed against
`find_flattening_point`.

`require_close` compares `fabs(a - b) > tolerance`, which is false for
NaN, so NaN passed every float assertion. Wrapped locally with an explicit
finite check rather than changing shared test code.

Also added `pad_to_max` coverage and truncation *content* -- previously
only its length was asserted.

Mutation-checked, each reverted after: transposed basis, mean dropped on
both legs, scale dropped on both legs, and zero-window search instead of
the thresholds. All four now fail the suite; all four passed it before.

The sampler residual
--------------------
Re-dumping the reference at float16 to match the GGUF (it defaults to
bfloat16) moves the denoiser probe from cosine 0.999977 to 0.999999188 and
the 40-step trajectory from 0.905481 to 0.976972. Combined with the
earlier step-count sweep -- 0.9965 at 4 steps, 0.9055 at 40 -- and Codex
finding no defect in a line-by-line read of the sampler against
inference.py, the residual is accumulating per-step rounding amplified by
dual CFG at 3.0/8.0, not a structural defect. That is an explanation, not
a proof: the trajectory is still reported as below the gate.

Documentation
-------------
docs/community_models/echo_tts.md claimed "No cosine >= 0.999 comparison"
and "No C++ unit tests are registered". Both were false at HEAD. It also
used a 0 % WER on 32 words to rule out four silent-failure modes,
including the speaker patchify reshape -- but WER scores words, not
speaker identity, so that one could produce fluent correct text in the
wrong voice and still read 0 %. Rewritten: both cosines published in one
table, the trajectory marked below gate, WER demoted to what it actually
shows, and the hand-run numbers separated from what CI holds.

Dropped the internal planning docs again -- a third `git add -A` re-added
them in 85ee6d8. Now in .git/info/exclude so it cannot recur.
…for it

Echo-TTS needs continuous z_q latents from the shared Fish codec, and the
way that was wired in changed the encode graph for *every* fish_audio
request: `&z_q` was passed unconditionally, so an extra `ggml_sub` node was
built and marked `ggml_set_output` even though fish_audio never reads it.

The arithmetic was unaffected, but the allocation was not. `ggml_set_output`
pins that buffer and keeps both `x` and the quantiser residual live to the
end of the graph, where otherwise the residual is free to be reused after
the last `quantize_one`. That is a real change to a supported core family
that gains nothing from it -- and one with no test in the tree and no
fish_audio checkpoint on this machine to catch it.

`EncodeGraph` now takes `want_z_q`. With it false, `build_encode_quantizer`
receives nullptr, no sub node is created, no output is set, and nothing is
expanded onto the graph -- so the construction sequence is identical to
upstream's. `encode_reference` passes false; only `encode_zq` passes true.

`matches()` gained the flag so a codes-only request cannot silently reuse a
z_q-bearing graph in the wrong direction. A graph that has z_q may serve a
codes-only request (the codes are identical either way); the reverse forces
a rebuild. In practice the two never mix on one codec instance -- fish_audio
only calls encode_reference and echo_tts only calls encode_zq -- so
fish_audio never gets the z_q-bearing graph at all.

`read_z_q` now throws instead of dereferencing a null output tensor.

The decode side needed nothing: `build_decode_quantizer` was split into
`build_zq_from_codes` + `build_decode_from_zq` and reassembled as a
composition of the two, which builds the same ops in the same order. The PR
body called that "restructured"; it is a pure refactor and has been
corrected.

Verified by execution, Echo-TTS on CUDA with the F16 GGUF: RC 0, 7.82 s
wall, 4.60 s of 44.1 kHz mono, peak 0.714, and an ASR round trip of
**WER 0.0 %, 0 edits on 14 words**. The z_q path still produces correct
speaker conditioning with the output now opt-in.
@5uck1ess
5uck1ess marked this pull request as ready for review August 20, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new model Request for new model support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants